React Server Side Rendering
This example explains how to use Piscina for server-side rendering (SSR) of React components. We'll compare a pooled version using Piscina with an unpooled version to highlight the benefits of using a thread pool for SSR.
To get started, make sure you have the following dependencies installed:
npm install fastify fastify-piscina react react-dom lorem-ipsum
If you are using TypeScript in your project, also install:
npm install -D typescript @types/react @types/react-dom @types/node
The function in the worker file uses ReactDOMServer.renderToString()
to render two React components: Greeting
and Lorem
.
- Javascript
- Typescript
'use strict';
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const { Greeting, Lorem } = require('./components');
module.exports = ({ name }) => {
return `
<!doctype html>
<html>
<body>
<div id="root">${
ReactDOMServer.renderToString(React.createElement(Greeting, { name }))
}</div>
${
ReactDOMServer.renderToString(React.createElement(Lorem))
}
<script src="/static/home.js"></script>
</body>
</html>`;
};
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { Greeting, Lorem } from './components';
export const filename = __filename;
interface WorkerInput {
name: string;
}
export function renderPage({ name }: WorkerInput): string {
return `
<!doctype html>
<html>
<body>
<div id="root">${
ReactDOMServer.renderToString(React.createElement(Greeting, { name }))
}</div>
${
ReactDOMServer.renderToString(React.createElement(Lorem))
}
<script src="/static/home.js"></script>
</body>
</html>`;
}
Here are the components used in this example:
- Javascript
- Typescript
'use strict';
const React = require('react');
class Greeting extends React.Component {
render () {
return React.createElement('div', null, 'hello ' + this.props.name);
}
}
module.exports = Greeting;
'use strict';
const React = require('react');
const { LoremIpsum } = require('lorem-ipsum');
class Paragraph extends React.Component {
#lorem;
constructor (props) {
super(props);
this.#lorem = new LoremIpsum({
sentencesPerParagraph: {
max: 8,
min: 4
},
wordsPerSentence: {
max: 16,
min: 4
}
});
}
render () {
return React.createElement('div', null, this.#lorem.generateParagraphs(1));
}
}
class Lorem extends React.Component {
render () {
const children = [];
for (let n = 0; n < Math.floor(Math.random() * 50); n++) {
children.push(React.createElement(Paragraph, { key: n }));
}
return React.createElement('div', null, children);
}
}
module.exports = Lorem;
import React from 'react';
interface GreetingProps {
name: string;
}
class Greeting extends React.Component<GreetingProps> {
render() {
return React.createElement('div', null, 'hello ' + this.props.name);
}
}
export default Greeting;
import React from 'react';
import { LoremIpsum } from 'lorem-ipsum';
class Paragraph extends React.Component {
private lorem: LoremIpsum;
constructor(props: {}) {
super(props);
this.lorem = new LoremIpsum({
sentencesPerParagraph: {
max: 8,
min: 4
},
wordsPerSentence: {
max: 16,
min: 4
}
});
}
render() {
return React.createElement('div', null, this.lorem.generateParagraphs(1));
}
}
class Lorem extends React.Component {
render() {
const children = [];
for (let n = 0; n < Math.floor(Math.random() * 50); n++) {
children.push(React.createElement(Paragraph, { key: n }));
}
return React.createElement('div', null, children);
}
}
export default Lorem;
Pooled Version
In pooled version of the code, we set up a Fastify server with Piscina integration. We register the fastify-piscina
plugin, configuring it to use the worker.js
file with 6 threads. The root route (/
) uses fastify.runTask()
to execute the rendering in a worker thread.
- Javascript
- Typescript
'use strict';
const fastify = require('fastify')();
const { resolve } = require('path');
fastify.register(require('fastify-piscina'), {
filename: resolve(__dirname, 'worker.js'),
execArgv: [],
minThreads: 6,
maxThreads: 6
});
// Declare a route
fastify.get('/', async () => fastify.runTask({ name: 'James' }));
// Run the server!
const start = async () => {
try {
await fastify.listen(3000);
} catch (err) {
process.exit(1);
}
};
start();
process.on('SIGINT', () => {
const waitTime = fastify.piscina.waitTime;
console.log('\nMax Queue Wait Time:', waitTime.max);
console.log('Mean Queue Wait Time:', waitTime.mean);
process.exit(0);
});
import { resolve } from 'path';
import { filename } from './worker';
const fastify = require('fastify')();
fastify.register(require('fastify-piscina'), {
filename: resolve(__dirname, 'workerWrapper.js'),
workerData: { fullpath: filename },
execArgv: [],
minThreads: 6,
maxThreads: 6
});
// Declare a route
fastify.get('/', async () => fastify.runTask({ name: 'James' }, { name: 'renderPage' }));
// Run the server!
const start = async () => {
try {
await fastify.listen(3000);
} catch (err) {
process.exit(1);
}
};
start();
process.on('SIGINT', () => {
const waitTime = (fastify as any).piscina.waitTime;
console.log('\nMax Queue Wait Time:', waitTime.max);
console.log('Mean Queue Wait Time:', waitTime.mean);
process.exit(0);
});
const { workerData } = require('worker_threads');
if (workerData.fullpath.endsWith(".ts")) {
require("ts-node").register();
}
module.exports = require(workerData.fullpath);
Unpooled Version
In the unpooled version of the server, we perform the same rendering as the pooled version, but with no thread pool or worker management. The root route (/
) renders the React components synchronously in the request handler.
- Javascript
- Typescript
'use strict';
const fastify = require('fastify')();
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const { Greeting, Lorem } = require('./components');
// Declare a route
fastify.get('/', async () => {
const name = 'James';
return `
<!doctype html>
<html>
<body>
<div id="root">${
ReactDOMServer.renderToString(React.createElement(Greeting, { name }))
}</div>
${
ReactDOMServer.renderToString(React.createElement(Lorem))
}
<script src="/static/home.js"></script>
</body>
</html>`;
});
// Run the server!
const start = async () => {
try {
await fastify.listen(3000);
} catch (err) {
process.exit(1);
}
};
start();
import fastify from 'fastify';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { Greeting, Lorem } from './components';
const server = fastify();
// Declare a route
server.get('/', async () => {
const name = 'James';
return `
<!doctype html>
<html>
<body>
<div id="root">${
ReactDOMServer.renderToString(React.createElement(Greeting, { name }))
}</div>
${
ReactDOMServer.renderToString(React.createElement(Lorem))
}
<script src="/static/home.js"></script>
</body>
</html>`;
});
// Run the server!
const start = async () => {
try {
await server.listen(3000);
} catch (err) {
process.exit(1);
}
};
start();
Run either the pooled or unpooled version:
- Pooled
- Unpooled
node pooled.js
node unpooled.js
Access http://localhost:3000
in your browser to see the rendered page.
Benchmarking Results
We used autocannon
to benchmark both the pooled and unpooled versions.
Install autocannon:
npm i -g autocannon
Then benchmark the results by running the script below:
autocannon http://localhost:3000
Here are the results:
Pooled Version Result
Running 10s test @ http://localhost:3000
10 connections
┌─────────┬──────┬──────┬───────┬───────┬────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼──────┼──────┼───────┼───────┼────────┼──────────┼────────┤
│ Latency │ 2 ms │ 4 ms │ 44 ms │ 63 ms │ 6.9 ms │ 10.98 ms │ 190 ms │
└─────────┴──────┴──────┴───────┴───────┴────────┴──────────┴────────┘
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬─────────┬────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼────────┼────────┼─────────┼─────── ──┼─────────┼─────────┼────────┤
│ Req/Sec │ 657 │ 657 │ 1,225 │ 2,067 │ 1,348.9 │ 454.88 │ 657 │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤
│ Bytes/Sec │ 2.3 MB │ 2.3 MB │ 4.35 MB │ 7.12 MB │ 4.68 MB │ 1.55 MB │ 2.3 MB │
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴─────────┴────────┘
Req/Bytes counts sampled once per second.
# of samples: 10
13k requests in 10.07s, 46.8 MB read
Unpooled Version Result
Running 10s test @ http://localhost:3000
10 connections
┌─────────┬──────┬───────┬───────┬───────┬──────────┬──────────┬────────┐
│ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │
├─────────┼──────┼───────┼───────┼───────┼──────────┼────── ────┼────────┤
│ Latency │ 8 ms │ 22 ms │ 63 ms │ 75 ms │ 25.03 ms │ 14.78 ms │ 165 ms │
└─────────┴──────┴───────┴───────┴───────┴──────────┴──────────┴────────┘
┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬────────┬────────┐
│ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
│ Req/Sec │ 234 │ 234 │ 419 │ 590 │ 395.6 │ 127.26 │ 234 │
├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤
│ Bytes/Sec │ 785 kB │ 785 kB │ 1.47 MB │ 1.97 MB │ 1.36 MB │ 439 kB │ 785 kB │
└───────────┴────────┴────────┴─────────┴─────────┴─────────┴────────┴────────┘
Req/Bytes counts sampled once per second.
# of samples: 10
4k requests in 10.22s, 13.6 MB read
The pooled version using Piscina offers several advantages for server-side rendering:
- It handles significantly more requests per second (1,348.9 vs 395.6 on average).
- It has lower latency (6.9ms vs 25.03ms on average).
- It processes more data (4.68 MB/sec vs 1.36 MB/sec on average).
You can also check out this example on github.